Use GeoFire for react-native app

May 05 2018

NodeReactFirebase

React Native, GeoFire, and Firebase

I want to simulate a realtime 'search this area' feature in my new react-native app. The raw data is already fetched from other services, but it only provides the address object contains organization / street / city / zip. And what I want is a feature that people could select an area on the map, clickable markers will pop up based on selection, click to fetch data for detailed content. So some work is needed before use these location information.

What I thought is 2 steps:

  1. In my Node serve, combine those address info and convert into geo locations which is used in google map, then save those locations into database

  2. In my react-native app, create geo queries automatically when user fire a new request.

Handle raw data in the server

Convert string to geo object

In the fetched data, there's no lat/lng variables exists, so I wrote a function to convert address string to geo object in the server.

//call google api to convert a string into geo object
const addressToGeo = async dataObj => {
  let addressStr = `${dataObj.name} ${dataObj.address}  ${dataObj.city} ${
    dataObj.zip
    }`;
  let result = await googleMapsClient
    .geocode({ address: addressStr })
    .asPromise()
    .then(response => {
      return response.json.results[0].geometry.location;
    })
    .catch(err => {
      console.log('addrToGeo err-- orgID: ',dataObj.orgID, err);
      return null;
    });
  return result;
};

Data parse and commit into firebase realtime database

Create a function to open read stream to read data file line by line, then for each line fire geofire's set promise to commit into firebase's realtime db.

const dataParse = async filePath =>{
  let total = 0;
  const rl = readline.createInterface({
    input: fs.createReadStream(filePath),
    terminal: false
  });
  rl.on("line", async line => {
    let dataObj = JSON.parse(line);
    let latlong = await addressToGeo(dataObj);
    let arr = [latlong.lat,latlong.lng]
    // call geofire.set to create its special geo object, and save into dbRef automatically
    geoFire.set(dataObj.orgID, arr).then(function() {
      console.log(`org ${dataObj.orgID} added to GeoFire`);
      total += 1;
    }, function(error) {
      console.log(`err: org ${dataObj.orgID} ${error}`);
    });
  });
  rl.on('close',()=>{
    console.log('closed, total added: ', total)
  })
}

Finally call the functions asynchronously to process all the data files.

const orgsGeoPointsUploadToDatabase = async () => {
  let fileIndex = 1;
  const prefix = keys.devKeys.prefix;
  while (fileIndex > 0) {
    let fileName = prefix + "_orgs_" + fileIndex + ".json";
    let filePath = path.join(__dirname, dataLocation, fileName);
    if (validateFileExist(filePath)) {
      dataParse(filePath, fileIndex);
      fileIndex++;
    } else {
      fileIndex = -1;
    }
  }
};

About tech selection for realtime 'nearby' feature

For firebase, there are 2 ways to achieve Geo Query.

  1. Use Firestore and algolia.
  2. Use GeoFire and realtime db.

Firestore….well it's new and still in Beta, I'm using it in my app, but I found out that the native Geo Query feature has not been implemented yet, so a third-party tool 'Algolia' is needed. It's a temporary solution and a paid service with fixed bandwidth/price, which is not ideal for this kind of small apps.

After some search, I decided to choose GeoFire as it's more stable and reliable, but Firestore as the newest tech it will surely replace the use of Geofire and realtime db in the future, so I will write some related steps for it in the end, but the code is mainly focused on Geofire option.

GeoFire it only works with firebase realtime db, which is the core db of firebase. Now Google is moving to next generation (Firestore), so it's been a while since its last update. For me, most of my data is on Firestore, and it looks redundant and complex to work with 2 very similar databases, but we can separate only the geo location part and use it with realtime db here. Only when user try to click titles/markers from realtime db, the app will fetch data from Firestore. This makes GeoFire workable and it also benefits loading performance.

Use GeoFire and Firebase realtime database

Save geo object into realtime db

As the server

To use the powerful geo query( such as query a list of the shops based on one geo location and a radius)

In constructor:

GeoFire uses .on event handler to listen to 4 eventType

ready fires once when this query's initial state has been loaded from the server. The ready event will fire after all other events associated with the loaded data have been triggered. ready will fire again once each time updateCriteria() is called, after all new data is loaded and all other new events have been fired.

key_entered fires when a key enters this query. This can happen when a key moves from a location outside of this query to one inside of it or when a key is written to GeoFire for the first time and it falls within this query.

key_exited fires when a key moves from a location inside of this query to one outside of it. If the key was entirely removed from GeoFire, both the location and distance passed to the callback will be null.

key_moved fires when a key which is already in this query moves to another location inside of it.

// create an instance of Geofire with the reference in my firebase realtime db.
this.geoFire = new GeoFire(firedb.ref("orgs"));
this.geoQuery = this.geoFire.query({
    center: [this.state.region.latitude, this.state.region.longitude],
    radius: 1000
});
// event listeners
this.geoQuery.on("ready", function() {
  console.log("GeoQuery has loaded and fired all other events for initial data");
});
this.geoQuery.on("key_entered", function(key, location, distance) {
  console.log(key + " entered query at " + location + " (" + distance + " km from center)");
});
this.geoQuery.on("key_exited", function(key, location, distance) {
  console.log(key + " exited query to " + location + " (" + distance + " km from center)");
});
this.geoQuery.on("key_moved", function(key, location, distance) {
  console.log(key + " moved within query to " + location + " (" + distance + " km from center)");
});

Create a function to update the geoQuery

updateCriteria = (lat, lng, rad) => {
  this.geoQuery.updateCriteria({
    center: [lat, lng],
    radius: rad
  });
};	

Each time when the updateCriteria is called with parameters, it will update this.geoQuery, and Geofire will calculate based on the new parameters.

At first, I use '.setState' directly in 'keyentered' and 'keyexited' callback function, it works, but the app would be laggy when there are many geo points enter or exit at the same time, it's because when the state changes, it will generates map markers based on those geo locations, and the map will re-render. So for some queries, the map may render tens or hundreds of times, which is totally unnecessary.

For better performance, I created an object 'this.markersHolder' to solve this issue.

Each time keyentered or keyexited fired, it pushes/deletes for each event, and set state dataLoading = true.

After all done, the ready event is fired, in its callback, parse object into array and setState. So the react-native-maps will receive all the markers' info and update them in one render.

PS:

  1. dataLoading could be used as loading spinner indicator.

  2. After react 16.3, componentWillMount is deprecated, so define geoFire, init geoQuery and create event listeners in constructor.

    this.geoQuery.on("ready", () => { console.log( "GeoQuery has loaded and fired all other events for initial data" ); let arr = []; Object.keys(tempArr).map(marker => { arr.push(tempArr[marker]); }); this.setState({ markersList: arr, dataLoading: false }); }); this.geoQuery.on("keyentered", (key, location, distance) => { console.log(entered ${key}); this.setState({ dataLoading: true }); tempArr[key] = { key, location, distance }; }); this.geoQuery.on("keyexited", key => { this.setState({ dataLoading: true }); console.log(${key} exited, ${this.state.markersList.length} still in); delete tempArr[key]; console.log(${this.state.markersList.length} left); });

Extra: Use Algolia and Firestore

Save geo object into firestore as GeoPoint

The GeoPoint is supported by firestore since the beginning.

It's an object marked as 'geopoint' in firestore, with format

{
    _lat: double number,
    _lng: double number
}

Write a function to save the geo object into firestore as geopoint for latter use

let latlong = await addressToGeo(dataObj);
dataObj.geoLocation = new firebase.firestore.GeoPoint(latlong.lat, latlong.lng);

Use algolia

Algolia a great third-party lib, combine with firestore to achieve the 'nearby' feature, it is the solution that google suggests.

For implementation, as the API is changing, the best way is to check their document here.